package il.ac.tau.cvws.scrabblesidekick.server.engine.recoginition.calibration.colorbasedcalibration;

import il.ac.tau.cvws.scrabblesidekick.server.app.manager.GameManager;
import il.ac.tau.cvws.scrabblesidekick.server.constant.FileConstants;
import il.ac.tau.cvws.scrabblesidekick.server.engine.recoginition.Recognizer;
import il.ac.tau.cvws.scrabblesidekick.server.engine.recoginition.TileImageMap;
import il.ac.tau.cvws.scrabblesidekick.server.engine.recoginition.calibration.colorbasedcalibration.comparator.ClockewiseComparator;
import il.ac.tau.cvws.scrabblesidekick.server.engine.recoginition.calibration.common.CalibrationCompositeData;
import il.ac.tau.cvws.scrabblesidekick.server.engine.recoginition.calibration.common.CalibrationConstants;
import il.ac.tau.cvws.scrabblesidekick.server.engine.recoginition.calibration.exception.CalibrationException;
import il.ac.tau.cvws.scrabblesidekick.server.util.cv.CvUtil;
import il.ac.tau.cvws.scrabblesidekick.server.util.debug.DebugUtil;
import il.ac.tau.cvws.scrabblesidekick.server.util.debug.DebugUtil.DebugTypes;
import il.ac.tau.cvws.scrabblesidekick.server.util.exception.ExceptionConstant;
import il.ac.tau.cvws.scrabblesidekick.server.util.exception.ScrabbleSidekickException;
import il.ac.tau.cvws.scrabblesidekick.server.util.file.FileUtil;
import il.ac.tau.cvws.scrabblesidekick.server.util.math.MathUtil;
import il.ac.tau.cvws.scrabblesidekick.shared.datatype.game.Board;
import il.ac.tau.cvws.scrabblesidekick.shared.datatype.game.Tile;
import il.ac.tau.cvws.scrabblesidekick.shared.util.math.Point;
import il.ac.tau.cvws.scrabblesidekick.shared.util.math.Vector;

import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

import com.googlecode.javacv.JavaCV;
import com.googlecode.javacv.cpp.opencv_core;
import com.googlecode.javacv.cpp.opencv_core.CvMat;
import com.googlecode.javacv.cpp.opencv_core.CvRect;
import com.googlecode.javacv.cpp.opencv_core.IplImage;

/* Detect the Scrabble board by looking for the red squares at the corners of the board
 * and at the middle of the board's edges.
 */
public class ColorBasedCalibrationRecognizer implements Recognizer
{
	private static final int HUE_LOWER_RED = 0;
	private static final int HUE_UPPER_RED = 5;
	
	@Override
	public CalibrationCompositeData recognize(IplImage image) throws ScrabbleSidekickException
	{
		IplImage hsvThresholdImage	= null;
		IplImage invertedImage		= null;
		IplImage cleanImage			= null;
		IplImage workingImage		= null;
		
		try
		{
			// Convert the red corners (only) to black
			hsvThresholdImage = ColorBaseCalibrationUtil.getHueThreshold(image, HUE_LOWER_RED, HUE_UPPER_RED);
			invertedImage = CvUtil.invertImage(hsvThresholdImage);
			
			// Remove artifacts
			cleanImage = ColorBaseCalibrationUtil.removeArtifacts(invertedImage);
			
			// Save working copy
			String workingCopyFileName = FileConstants.TEMP_FILE_NAME;
			CvUtil.saveImage(cleanImage, workingCopyFileName);
			workingImage = CvUtil.loadImage(workingCopyFileName, true);
			FileUtil.deleteFile(FileConstants.WORKING_DIR + workingCopyFileName);
			
			/*
			 * sorted clockwise : p0----p1
			 *                    |     |
			 *                    |     |
			 *                    p3----p2
			 */
			List<Point> corners = findBoardCorners(workingImage);
			
			// Apply the perspective transformation appropriate for the corner points,
			// and then extract the images of the tiles
			TileImageMap tileImageMap = extractSubImages(image, corners);
			
			return new CalibrationCompositeData(tileImageMap, null);
		}
		catch (CalibrationException e)
		{
			throw e; // rethrow CalibrationException to avoid the clause below
		}
		catch (Exception e)
		{
			throw new CalibrationException(ExceptionConstant.GENERAL_FAILURE, e.getCause());
		}
		finally
		{
			if (hsvThresholdImage != null)	hsvThresholdImage.release();
			if (invertedImage != null)		invertedImage.release();
			if (cleanImage != null)			cleanImage.release();
			if (workingImage != null)		workingImage.release();
		}
	}
	
	/* Rectifies the board by applying a perspective transformation, in order to get
	 * an image of a square board, from which we crop the game tiles.
	 */
	private TileImageMap extractSubImages(IplImage image, List<Point> corners)
	{
		/*
		 * sorted clockwise : p0----p1                                p0----p1
		 *                    /     /      ->   Perspective ->        |     |
		 *                   /     /                                  |     |
		 *                 p3----p2                                   p3----p2
		 */
		
		int boardWidth = CalibrationConstants.BOARD_IMAGE_WIDTH;
		int boardHeight = CalibrationConstants.BOARD_IMAGE_HEIGHT;
		
		int stripeWidth = ColorBasedCalibrationConstants.SIDE_STRIPE_IMAGE_WIDTH;
		int stripeHeight = ColorBasedCalibrationConstants.SIDE_STRIPE_IMAGE_HEIGHT;
		
		int stripeOffsetY = ((stripeHeight > boardHeight) ? ((stripeHeight - boardHeight) / 2) : 0);
		
		double[] sourcePoints = {
				corners.get(0).getX(), corners.get(0).getY(),
				corners.get(1).getX(), corners.get(1).getY(),
				corners.get(2).getX(), corners.get(2).getY(),
				corners.get(3).getX(), corners.get(3).getY() };
		
		int bevelX = (int)(boardWidth * (0.5 / (Board.SQUARES_NUM - 1)));
		int bevelY = (int)(boardHeight * (0.5 / (Board.SQUARES_NUM - 1)));
		
		double[] targetPoints = {
				stripeWidth + bevelX, stripeOffsetY + bevelY,
				stripeWidth + boardWidth - bevelX, stripeOffsetY + bevelY,
				stripeWidth + boardWidth - bevelX, stripeOffsetY + boardHeight - bevelY,
				stripeWidth + bevelX, stripeOffsetY + boardHeight - bevelY };
		
		CvMat perspectiveMat = CvMat.create(3, 3, opencv_core.CV_32F);
		JavaCV.getPerspectiveTransform(sourcePoints, targetPoints, perspectiveMat);
		
		int totalWidth = boardWidth + (2 * stripeWidth);
		int totalHeight = Math.max(boardHeight, stripeHeight);
		
		IplImage completeImage = CvUtil.warpImagePerspective(image, totalWidth, totalHeight, perspectiveMat);		
		IplImage boardImage = CvUtil.cropImage(completeImage, stripeWidth, stripeOffsetY, boardWidth, boardHeight);
		
		TileImageMap tileImageMap = extractTileImages(boardImage);
		
		return tileImageMap;
	}
	
	/* Find the board's corners by grouping detected edge points and finding the
	 * square shapes.
	 */
	private List<Point> findBoardCorners(IplImage imgSrc) throws CalibrationException
	{
		Set<Point> edges = ColorBaseCalibrationUtil.findEdges(imgSrc);		
		
		List<Set<Point>> squares = findSquares(edges);
		
		if (squares.size() != Board.CORNERS_NUM)
		{
			throw new CalibrationException(CalibrationException.INVALID_INPUT_IMAGE_RED_DETECTION);
		}
		
		Set<Point> midPoints = new HashSet<Point>();
		
		for (int j = 0; j < squares.size() ; j ++)
		{			
			removeArtifacts(imgSrc.width(), imgSrc.height(), imgSrc.getBufferedImage(), squares.get(j));

			midPoints.add(MathUtil.getMidPoint(squares.get(j)));
		}
		
		Set<Point> cornerSet = calculateCorners(midPoints);

		if (cornerSet.size() != 4)
		{
			throw new CalibrationException(CalibrationException.INVALID_INPUT_IMAGE_RED_DETECTION);
		}
		
		Point leftUpperPoint = findLeftUpperPoint(cornerSet);
		
		List<Point> cornerSortedList = new ArrayList<Point>(cornerSet);
		Collections.sort(cornerSortedList, new ClockewiseComparator(leftUpperPoint));
		
		return cornerSortedList;
	}
	
	/* Find the squares in the image, and group the given edge points by square.
	 */
	private List<Set<Point>> findSquares(Set<Point> edges)
	{
		List<Set<Point>> squares = new ArrayList<Set<Point>>();

		double maxDist = findSquareLimit(edges);
		
		int square = 0;
		
		while (!edges.isEmpty())
		{
			Iterator<Point> ite = edges.iterator();
			
			Point currPointPerSquare = ite.next();
						
			if ((square >= squares.size()) || (squares.get(square) == null))
			{
				squares.add(square, new HashSet<Point>());
			}
			
			squares.get(square).add(currPointPerSquare);
			
			Set<Point> tempSet = new HashSet<Point>();
			
			while (ite.hasNext())
			{
				Point currPoint = ite.next();
				
				if (currPointPerSquare.dist(currPoint) <= maxDist)
				{
					squares.get(square).add(currPoint);
				}
				else
				{
					tempSet.add(currPoint);
				}
			}	
			
			edges = tempSet;
			square++;
		}
		
		return squares;
	}
	
	/* This method removes from the set of border points the points which are not part of one
	 * of the board's edge squares.
	 */
	private void removeArtifacts(int width, int height, BufferedImage bufImage, Set<Point> borderPoints)
	{
		double maxDis = MathUtil.getMaxDistance(borderPoints);
		double radius = maxDis * ColorBasedCalibrationConstants.ARTIFACT_REMOVAL_NOISE_DISTANCE_RATIO;
		
		Set<Point> tempBorderPoints = new HashSet<Point>(borderPoints);
		
		/*		 \ | /
		 * 	      \|/
		 *     --- p ---
		 * 		  /|\
		 *       / | \	
		 */
		
		for (Point p : tempBorderPoints)
		{
			Point baseP = new Point(p.getX() + radius, p.getY(), 0.0);
			
			int blackCount  = 0;
			boolean isNoise = true;
			
			for (double ang = 0; ang < 4 * Math.PI;
				 ang += ((2 * Math.PI) / ColorBasedCalibrationConstants.ARTIFACT_REMOVAL_CIRCLE_STEPS))
			{
				double x = p.getX() + ((baseP.getX() - p.getX()) * Math.cos(ang)) - ((p.getY() - baseP.getY()) * Math.sin(ang));
				double y = p.getY() + ((p.getY() - baseP.getY()) * Math.cos(ang)) - ((baseP.getX() - p.getX()) * Math.sin(ang)); 
				
				if ((x >= 0) && (y < width) && 
				    (y >= 0) && (y < height))
				{
					if (isInnerBlackPoint(bufImage, p, new Point(x, y, 0.0)))
					{
						blackCount++; //in a row
						
						if (blackCount == 3)
						{
							isNoise = false;
							break;
						}
					}
					else
					{
						blackCount = 0; 
					}
				}
			}
			
			if (isNoise) //artifact! 
			{
				borderPoints.remove(p);
			}
		}
	}
	
	/* Is the given point in/on a black square?
	 */
	private boolean isInnerBlackPoint(BufferedImage bufImage, Point orgPoint, Point destPoint)
	{
		int col = bufImage.getRGB((int)destPoint.getX(), (int)destPoint.getY());
		
		if (col == -1)
		{
			return false;
		}
		
		Vector vec = new Vector(orgPoint, destPoint);
		
		double scaleRatio = 0.5;
		
		for (double step = scaleRatio; step < 1; step += scaleRatio)
		{
			Point currP = orgPoint.add(vec.scale(step));
			
			col = bufImage.getRGB((int)currP.getX(), (int)currP.getY());
			
			if (col == -1) // found a white point between orgPoint and destPoint
			{
				return false;
			}
		}
		
		return true;
	}
	
	/* Calculates which of the given border points constitute the corner points
	 * of the board. We do this by looking for triplets of points which make up
	 * the board's edges.
	 */
	private Set<Point> calculateCorners(Set<Point> borderPoints)
	{
		double epsilon = ColorBasedCalibrationConstants.CORNER_LOOKUP_EPSILON;
		
		List<Point> borderList = new ArrayList<Point>(borderPoints);
		Set<Point> corners = new HashSet<Point>();
		
		for (Point p1 : borderList)
		{
			boolean continuesFlag = false;
			boolean negateFlag    = false;
			
			for (Point p2 : borderList)
			{
				if (p1 == p2)
				{
					continue;
				}
				
				Vector baseVec = new Vector(p1, p2);
				
				for (Point p3 : borderList)
				{
					if ((p3 == p1) || (p3 == p2))
					{
						continue;
					}
					
					Vector currVec = new Vector(p1, p3);
					
					double dotProduct = baseVec.dot(currVec);
					double ratio      = dotProduct / (currVec.length() * baseVec.length());
				
					if ((ratio <= 1 + epsilon) && (ratio >= 1 - epsilon)) // p1---p2---p3
					{
						continuesFlag = true;
					}
					else if ((ratio <= -1 + epsilon) && (ratio >= -1 - epsilon)) // p2---p1---p3
					{
						negateFlag = true;
					}
					
					if (negateFlag)
					{
						break;
					}
				}
			}
			
			if ((!negateFlag) && (continuesFlag)) // a corner was found!
			{
				corners.add(p1);
			}
		}
		
		mergeClosestCorners(corners);
		
		return corners;
	}

	/*
	 *    leftUpperPoint --- p1
	 *        |              |
	 *        |              |
	 *        p3-------------p2
	 */
	private Point findLeftUpperPoint(Set<Point> corners)
	{
		double minDis = Double.MAX_VALUE;
		
		Point  closestPoint = null;
		Point  zeroPoint    = new Point(0.0, 0.0, 0.0); 
		
		for (Point p : corners)
		{
			double currDis = p.dist(zeroPoint);
			
			if (currDis < minDis)
			{
				closestPoint = p;
				minDis = currDis;
			}
		}
		
		return closestPoint;
	}
	
	private void mergeClosestCorners(Set<Point> corners)
	{
		double maxCornerDis = MathUtil.getMaxDistance(corners);

		HashSet<Point> tempCorners = new HashSet<Point>(corners);
		
		for (Point p1 : tempCorners)
		{
			if (!(corners.contains(p1)))
			{
				continue;
			}
			
			double sumX = p1.getX();
			double sumY = p1.getY();
			
			int count = 1;
			
			for (Point p2 : tempCorners)
			{
				if ((p1 != p2) && !(corners.contains(p2)))
				{
					continue;
				}
				
				if (p1.dist(p2) <= (maxCornerDis / 4))
				{
					count++;
					
					sumX += p2.getX();
					sumY += p2.getY();
					
					corners.remove(p2);
				}
			}
			
			if (count > 1)
			{
				corners.remove(p1);				
				corners.add(new Point(sumX / count, sumY / count, 0.0));
			}
		}
	}
	
	/* Crop the tiles from the rectified board image.
	 * The tiles are returned as a list of letter images.
	 */
	private List<IplImage> cropTiles(IplImage image)
	{
		List<IplImage> rawTileImages = new ArrayList<IplImage>();
		
		int tileCount = CalibrationConstants.LETTER_COUNT;
		
		double width = image.width();
		double height = image.height();
		
		int bevel = (int)((width / Board.SQUARES_NUM) * CalibrationConstants.SQUARE_CROP_BEVEL_RATIO);
		
		for (int i = 0; i < tileCount; i++)
		{
			int col = i % CalibrationConstants.TILES_IN_ROW;
			int row = i / CalibrationConstants.TILES_IN_ROW;
			
			int x1 = (int)((width * (Board.SQUARES_NUM - 2 - col)) / Board.SQUARES_NUM) - bevel;
			int x2 = (int)((width * (Board.SQUARES_NUM - 1 - col)) / Board.SQUARES_NUM) + bevel;
			int y1 = (int)((height * (Board.SQUARES_NUM - 2 - row)) / Board.SQUARES_NUM) - bevel;
			int y2 = (int)((height * (Board.SQUARES_NUM - 1 - row)) / Board.SQUARES_NUM) + bevel;
			
			IplImage rawTileImage = CvUtil.cropImage(image, x1, y1, x2 - x1, y2 - y1);			
			rawTileImages.add(rawTileImage);
		}
		
		return rawTileImages;
	}
	
	/* Crops the tiles on which the letters are laid, and the finds the letters themselves
	 * on the tiles. The result map is then filled and returned.
	 */
	private TileImageMap extractTileImages(IplImage boardImage)
	{
		List<IplImage> rawTileImages = cropTiles(boardImage);
		
		TileImageMap resultMap = new TileImageMap();
		
		for (int tileId = 0; tileId < rawTileImages.size(); tileId++)
		{
			IplImage rawTileImage = rawTileImages.get(tileId);
			
			IplImage tileThresholdImage = CvUtil.createThresholdImage(rawTileImage, 0.5);
			
			int minFrameWidth = (int)(CalibrationConstants.TILE_FRAME_MIN_RATIO * tileThresholdImage.width());
			int maxFrameWidth = (int)(CalibrationConstants.TILE_FRAME_MAX_RATIO * tileThresholdImage.width());
			int minFrameHeight = (int)(CalibrationConstants.TILE_FRAME_MIN_RATIO * tileThresholdImage.height());
			int maxFrameHeight = (int)(CalibrationConstants.TILE_FRAME_MAX_RATIO * tileThresholdImage.height());
			
			CvRect tileCleanRect = CvUtil.findCleanImageFrame(tileThresholdImage,
					minFrameWidth, maxFrameWidth, minFrameHeight, maxFrameHeight,
					CalibrationConstants.TILE_FRAME_THICKNESS);
			
			if (tileCleanRect == null)
			{
				throw new IllegalArgumentException();
			}
			
			IplImage tileCleanImage = CvUtil.cropImage(tileThresholdImage, tileCleanRect);

			if (GameManager.instance().debugMode())
			{
				DebugUtil.saveImage(tileCleanImage, DebugTypes.CALIBRATION, "tile_" + tileId, true);
			}
			
			resultMap.put(new Tile(tileId, 1), tileCleanImage);
		}
		
		return resultMap;
	}
	
	private double findSquareLimit(Set<Point> pointSet)
	{
		return (MathUtil.getMaxDistance(pointSet) / Math.sqrt(2.0)) / 3.0;	
	}
}
